組込み現場の「C++」プログラミング 明日から使える徹底入門

高木 信尚(株式会社クローバーフィールド

3.7 例外処理の裏側

例外処理は,C++の言語仕様の中でも,おそらく最も振る舞いを理解しにくいものではないかと思います.関数の枠組みを飛び越えて,一気に呼び出し元の関数にある例外ハンドラに制御が移るわけですから,理解しにくいのも無理はありません.

実はCにも,setjmpマクロとlongjmp関数という,例外処理によく似たライブラリ機能がありました.for文やswitch文などは,if文とgoto文を組み合わせれば同じ機能を実現することは可能ですが,使い方を固定することで可読性を向上させ,構造化プログラミングを容易にしています.同じように,例外処理は,setjmpマクロとlongjmpマクロの使い方を固定することで,可読性を向上させ,構造化することに容易にしています.また,longjmp関数は整数値しか送れませんでしたが,送出式は任意の型のオブジェクトを送ることができるため,より表現力に富んだ機能になっています.

setjmpやlongjmpを使う機会はそう多くありませんが,使用経験があるか,あるいは使用しているコードを見たことがある方であれば,なんとなく例外処理の仕組みが見えてくるはずです.

3.7.1 例外処理の振る舞い

例外処理について考えるとき,まずは例外処理が見かけ上どう振る舞うかを正確に把握しておく必要があります.ここでは,いわゆるprintfデバッグの手法を使って,例外として送出したオブジェクトの振る舞いを追跡してみることにしましょう.

#include <stdio.h>
class test
{
public:
    test(int num) : no(num)
    {
        printf("[construct %d]\n", no);
    }
    test(const test& other) : no(other.no + 1)
    {
        printf("[copy %d to %d]\n", other.no, no);
    }
    ~test()
    {
        printf("[destruct %d]\n", no);
    }
    int get() const
    {
        return no;
    }
private:
    int no;
};
void f()
{
    test x(0);
    throw x;
}
void g()
{
    test y(10);
    f();
}
int main()
{
    try
    {
        printf("[begin]\n");
        g();
        printf("[end]\n");
    }
    catch (test& e)
    {
        printf("[catch %d]\n", e.get());
    }
}

上記のコードでは,例外処理の振る舞いを追跡するためにtestクラスを定義しています.testクラスは,コンストラクタやデストラクタが呼び出されると,printf関数を使って報告するようになっています.このプログラムを実行すると,次のようになり,オブジェクトの振る舞いを追跡することができます.

[begin]
[construct 10]
[construct 0]
[copy 0 to 1]
[destruct 0]
[destruct 10]
[catch 1]
[destruct 1]

それでは,この実行結果を順に追ってみましょう.まず,[begin]というのは,main関数のtry節の最初の部分です.ソースコードでは,g関数を呼び出した後に[end]を出力していますが,実際には[end]は出力されておらず,その行には到達していないことがわかります.2行目の[construct 10]というのは,g関数で宣言したオブジェクトyのコンストラクタが実行されたことを意味しています.そして,3行目の[construct 0]は,f関数で宣言したオブジェクトxのコンストラクタです.4行目に現れる[copy 0 to 1]は,f関数の中で例外としてxを送出した際に,オブジェクトのコピーが発生したことを意味しています.xは自動記憶域期間を持つオブジェクトですので,そのままではf関数の外に持ち出すことができないからです.コピー先は動的に割り付けられることになりますが,仮にmalloc関数で割り付けられるメモリがすでになくても,最低限の領域が確保できるように,処理系はあらかじめある程度のメモリを予約しているようです.以後は例外が送出された後の振る舞いになります.5行目の[destruct 0]は,f関数から抜ける際にxのデストラクタが呼び出されたことを意味しています.6行目の[destruct 10]はmain関数まで戻る途中経路であるg関数の中のyのデストラクタが呼び出されたものです.そして,7行目のcatch 1で,送出されたオブジェクトがcatchされ,main関数のcatch節が実行されました.そこで宣言している参照eは,データメンバーnoが1ですので,送出されたオブジェクトそのものを参照していることになります.そして,最後の[destruct 1]で,送出されたオブジェクトのデストラクタが呼ばれています.

このように,例外が送出されると,途中の経路にある自動記憶域間を持つオブジェクトのデストラクタが順に呼び出されます.C++コンパイラは,途中経路にどんなデストラクタがあるかを知るための情報を,何らかの方法で生成することになります.当然ですが,そうした情報の分だけプログラムのサイズも大きくなります.

呼び出すべきデストラクタを検索する動作は,実測していただければわかるかと思いますが,非常に時間がかかります.そのため,よく発生しがちなエラーに例外処理を使うと,オーバーヘッドが大きすぎて使いものになりません.処理にかかる時間を見積もることも困難です.例外処理は便利な機能ではありますが,代わりに支払うべき代償も決して小さくありません.なんでもかんでも例外処理を使うのではなく,必要に応じて他の方法と使い分けることが大切です.

3.7.2 setjmpマクロとlongjmp関数を使った 例外処理の擬似コード

それでは,例外処理がどのように実現されているのかを理解するために,setjmpマクロとlongjmp関数を使った例外処理の擬似コードを書いてみることにします.ただし,C++の例外処理の仕様を完全に表現するのはたいへんですので,例外として送出できるのは整数値だけということにします.

まずは,途中の経路にある自動記憶域期間を持つオブジェクトのデストラクタ呼び出しは考慮しないことにします.

struct __jmp_buf_node
{
    jmp_buf __env;
    int __cause;
    struct __jmp_buf_node *__next;
};
struct __jmp_buf_node *__jmp_buf_node_top = NULL;
/* try節の擬似コード */
#define __try \
    struct __jmp_buf_node __node;  \
    __node.__next = __jmp_buf_node_top;  \
    __jmp_buf_node_top = &__node;  \
    if (setjmp(__node.__env) == 0)
/* catch節の擬似コード */
#define __catch(e) \
    else if (__node.__cause == (e))
/* throw式の擬似コード */
void __throw(int cause)
{
    struct __jmp_buf_node *ptr = __jmp_buf_node_top;
    if (ptr == NULL)
    {
        std::terminate();
    }
    else
    {
        puts("[throw]");
        __jmp_buf_node_top = ptr->__next;
        ptr->__cause = cause;
        longjmp(ptr->__env, 1);
    }
}
void bar()
{    puts("[bar]");
    __throw(1);
}
void foo()
{
    __try
    {
        puts("[try]");
        bar();
        puts("[end]");
    }
    __catch (1)
    {
        puts("[catch]");
    }
}

この擬似コードの中のfoo関数を実行すると,次のような出力結果が得られるはずです.

[try]
[bar]
[throw]
[catch]

すなわち,bar関数を呼び出した直後の[end]は出力されず,__catch節の中の[catch]が出力されるわけです.なお,本来であれば,最初のsetjmpマクロ実行後に更新される自動変数である__nodeはvolatile修飾子を付けるべきですが,可読性を下げるのでここでは省略しています.

ただし,この擬似コードでは,foo関数の中の__catch節で処理できなかった例外を,foo関数の呼び出し元の関数まで伝播させることができません.これを改善するには,foo関数の中の__catch節で処理できなかった例外を使って,再度__throw関数を呼び出さなければなりません.これを実現するには,次のように変更すればよいでしょう.

struct __jmp_buf_node
{
    jmp_buf __env;
    int __cause;
    int __caught;  /* このメンバーを追加 */
    struct __jmp_buf_node *__next;
};
struct __jmp_buf_node *__jmp_buf_node_top = NULL;
/* try節の擬似コード */
#define __try \
    struct __jmp_buf_node __node;  \
    __node.__next = __jmp_buf_node_top;  \
    __jmp_buf_node_top = &__node;  \
    for (__node.__caught = -1;  \
         __node.__caught == -1;  \
         __node.__caught ? (__node.__caught = 1) : __throw(__node.__cause))  \
        if (setjmp(__node.__env) == 0)
/* catch節の擬似コード */
#define __catch(e) \
    else if ((__node.__caught = (__node.__cause == (e))) != 0)

かなり複雑になってしまいましたが,これはシンタックスを同じにするために,強引にfor文を使ったためです.やっていることは,次のとおりです.

  1. __node.__caughtというメンバーを追加し,-1で初期化する.
  2. 例外が送出された場合,__catch節で処理できた場合は1に設定する.
  3. 例外が送出されたにもかかわらず,__catch節で処理できなければ0に設定する.
  4. __node.__caughtが0に設定されている場合に限り,再度__throw関数を呼び出す.

ここで挙げたコードは完全にCの範囲で記述しているので,注意深く読み解けば例外処理の実現方法が読み取れるはずです.もちろん,処理系によって実現方法は異なるので,必ずしもこの擬似コードのとおりにはなっていませんが,1つの実現方法を理解できれば,他の方法を理解するのも容易になることでしょう.なお,ここで1つ注目すべきなのは,setjmpマクロで環境を保存したり,例外要因を格納するために使用している__jmp_buf_node構造体は,自動変数__nodeとして定義しますが,__throw関数の中からグローバル変数である__jmp_buf_node_topを使って操作しているという点です.すなわち,もしマル チタスク環境でC++の例外処理を正しく動作させようとするなら,このグローバル変数をタスクごとに用意しなければならないのです.

3.7.3 途中のデストラクタを呼び出す擬似コード

次に,途中の経路にある自動記憶域期間を持つオブジェクトのデストラクタを呼び出す擬似コードを考えてみることにします.基本は先ほどの擬似コードのとおりです.

デストラクタを表現するための擬似コードとして,次のようなA構造体を使うことにします.

struct A
{
    int a;
};
void __dtor_A(struct A* __this)
{
    puts("[__dtor_A]");
}

これは,次のコードを表現したもので,__dtor_A関数はA::~Aを表しています.引数__thisはthisポインタの代わりです.

struct A
{
    ~A()
    {
        puts("[__dtor_A]");
    }
    int a;
};

そして,このA構造体を用いて,先ほどのfoo関数を次のように書き換えてみます.

void foo()
{
    __try
    {
        struct A a;
        puts("[try]");
        bar();
        puts("[end]");
    }
    __catch (1)
    {
        puts("[catch 1]");
    }
}

この例では,bar関数の中で例外が送出されたときに__dtor_A(&a)が呼び出されなければなりません.もう__tryマクロの中に隠し切れるコードだけでは実現することができません.そこで,見た目はやや汚くなりますが,下記のようにしてみました.

struct __jmp_buf_node
{
    jmp_buf __env;
    int __cause;
    int __caught;
    /* 以下の2つのメンバーを追加 */
    void (*__dtors)(void*);
    void* __args;
    struct __jmp_buf_node *__next;
};
/* foo関数の__try節から脱出するときに呼び出す関数 */
void __dtors_foo(void* arg){
    __dtor_A((struct A*)arg);
}
void foo()
{
    __try
    {
        struct A a;
        /* 次の2行で,デストラクタを呼び出す処理を登録する */
        __node.__dtors = &__dtors_foo;
        __node.__args = &a;
        puts("[try]");
        bar();
        puts("[end]");
        __dtors_foo(&a);
    }
    __catch (1)
    {
        puts("[catch 1]");
    }
}

foo関数の中に追加した2行により,aのデストラクタを呼び出すための処理を登録しています.ここでは,デストラクタを呼び出す必要があるオブジェクトはaだけですが,これが複数になった場合でも,__dtor_foo関数の中に必要な数だけデストラクタの呼び出しを追加すれば対応することができます.そして,__throwの中から__node.__dtorsを呼び出すことになります.

ここでも1点注目してほしいのですが,明示的なデストラクタを持つ自動オブジェクトの生存期間内で例外を送出するかもしれない処理を行う場合には,このようなデストラクタを呼び出すための準備を行う必要が発生するということです.今回の擬似コードでは,__try節の中に限ってお話していますが,実際のC++のコードでは,監視ブロック(try-Block)の中でなくても同じことがいえます.

void f()
{
    throw e;
}
void g()
{
    A a;
    f();
}
void h()
{
    try
    {
        g();
    }
    catch (E& e)
    {
    }
}

というのも,上記のようなコードがあった場合,g関数の中には監視ブロックがありませんが,f関数が例外を送出した場合には,aのデストラクタを呼び出す必要があるからです.なお,処理系によっては,このようなデストラクタを呼び出すための準備を必要としない実装になっていることもあります.その場合,デバッグ情報などを用いてデストラクタの呼び出しを実現するため,例外が送出されなければ,まったく余分なコストがかかりません.その代わり,g関数のような一見例外処理とは無縁に見える関数であっても,デストラクタの呼び出しを実現するための膨大なデータが関数定義とは別に埋め込まれることになります.そして,いったん例外が送出されると,その処理にかかる時間は桁違いに大きくなる傾向にあるようです.

例外処理の実現方法は処理系によってかなり癖があります.例外処理を使う場合には,使用する処理系の癖をある程度つかんでおく必要があります.